<?php
// +------------------------------------------------------------------
// | Cls-Token [ USER LOGIN AUTHENTICATION TOKEN ]
// | Copyright (c) 2024 All rights reserved.
// | Based on ThinkPHP 6 | 8
// | Licensed Mulan PSL v2 ( http://license.coscl.org.cn/MulanPSL2 )
// | Author: CLS <422064377>
// | CreateDate: 2024-07-15
// +------------------------------------------------------------------

namespace chleniang\ClsToken;

use chleniang\ClsToken\exception\ClsTokenEmptyException;
use chleniang\ClsToken\exception\ClsTokenException;
use chleniang\ClsToken\exception\ClsTokenExpiredException;
use chleniang\ClsToken\exception\ClsTokenValidateException;
use chleniang\ClsToken\util\Util;

abstract class Driver
{
    /**
     * 驱动句柄
     */
    protected object|null $handler = null;

    protected static string $default_secret_key = 'tk_sec_key_@sudf91!hj8us7Jhc7Vbd9$u7';

    /**
     * 驱动配置参数
     *      (具体存储标识的配置参数)
     * @var array
     */
    protected array $options = [];


    /**
     * 基础配置参数(除"default","stores"之外的所有配置)
     * @var array
     */
    protected array $base_options = [
        // 令牌存储时 hash_hmac() 加密算法
        //    (默认"" 不加密) 常用的有 "sha256" / "md5" / "ripemd160" / "haval160,4"
        "save_algo"       => "",
        // 令牌存储时的加密密钥(盐),如改变所有已存储token将失效
        "save_secret_key" => 'cls_token_sec_us77@sudf91!hjVbd9$u7',

        // 令牌-过期时间(秒)
        "expire"          => 60,

        // 刷新令牌-是否启用  true:启用(默认) / false:不使用刷新令牌
        "refresh_token"   => false,
        // 刷新令牌-过期时间(秒) 启用刷新令牌时才有用,过期时间应远大于token的过期时间
        "refresh_expire"  => 3600,

        // 鉴权模式  1 / 2 / 3
        //         1:只检查token/refresh_token正确性且未过期
        //         2:在1的基础上,同时检查 来访user_agent与登录时创建的记录是否一致
        //         3:在2的基础上,同时检查 来访ip与创建记录的是否一致(慎用,移动应用ip可能会变))
        "check_mode"      => 2,
    ];

    /**
     * @param array $options 参数
     *                       [
     *                       "driver" => [ 当前驱动相关配置 ],
     *                       "base" => [ 基础配置(或者叫 公共配置__除 "default" "stores" 之外的所有配置项) ]
     *                       ]
     */
    protected function __construct(array $options = [])
    {
        if (!empty($options)) {
            $baseKeys = array_keys($this->base_options);
            foreach ($options as $key => $value) {
                if (in_array($key, $baseKeys)) {
                    $this->base_options[$key] = $value;
                }
                else {
                    $this->options[$key] = $value;
                }
            }
        }
    }

    /**
     * 生成Token
     *      =登录成功后生成token记录; 含 token 及 refresh_token(如果有的话)
     *
     * @param int|string $userIdentifier 用户唯一标识(ID/UUID)
     * @param array      $userExInfo     token记录中要保存的用户其他信息(不要太多)
     *
     */
    final public function buildToken(
        int|string $userIdentifier,
        array      $userExInfo = []
    ): array
    {
        $req     = request();
        $timeNow = time();

        $returnData = [
            'token'  => Util::randomString('token' . $userIdentifier),
            'expire' => $this->base_options['expire'] ?? 0,
        ];

        $setData = [
            'token'           => $returnData['token'],
            'expire'          => $timeNow + $returnData['expire'],
            'user_identifier' => (string)$userIdentifier,
            'refresh_token'   => '',
            'refresh_expire'  => 0,
            'ex_info'         => $userExInfo,
            'user_agent'      => Util::hashUA($req->header('user-agent', '')),
            'ip'              => $req->ip(),
            'update_time'     => $timeNow,
        ];

        if (!empty($this->base_options['save_algo'])) {
            $key = empty($this->base_options['save_secret_key']) ?
                self::$default_secret_key
                : $this->base_options['save_secret_key'];

            $setData['token'] = hash_hmac(
                $this->base_options['save_algo'],
                $returnData['token'],
                $key
            );
        }

        if ($this->base_options['refresh_token'] === true) {
            // 使用刷新token
            $returnData['refresh_token']  = Util::randomString('refresh_token' . $userIdentifier);
            $returnData['refresh_expire'] = $this->base_options['refresh_expire'];
            $setData['refresh_expire']    = $timeNow + $this->base_options['refresh_expire'];
            if (!empty($this->base_options['save_algo'])) {
                $key = empty($this->base_options['save_secret_key']) ?
                    self::$default_secret_key
                    : $this->base_options['save_secret_key'];

                $setData['refresh_token'] = hash_hmac(
                    $this->base_options['save_algo'],
                    $returnData['refresh_token'],
                    $key
                );
            }
            else {
                $setData['refresh_token'] = $returnData['refresh_token'];
            }
        }

        $this->set($setData);

        return $returnData;
    }

    /**
     * 通过 refresh_token 及 用户标识 更新 token
     *
     * @param string     $refreshToken   刷新令牌
     * @param int|string $userIdentifier 用户唯一标识(id/uuid)
     *
     * @throws ClsTokenException
     */
    final public function updateToken(
        string     $refreshToken,
        int|string $userIdentifier
    )
    {
        $timeNow = time();

        $tokenInfo = $this->getOriginData($userIdentifier);

        if (empty($tokenInfo['refresh_token'])) {
            throw new ClsTokenEmptyException([
                'msg' => '[RX]Token is empty.',
            ]);
        }
        if (strtolower($this->hashToken($refreshToken)) !== strtolower($tokenInfo['refresh_token'])) {
            throw new ClsTokenValidateException([
                'msg' => '[RV]Token validate failed.',
            ]);
        }
        if (strtolower($userIdentifier) !== strtolower($tokenInfo['user_identifier'])) {
            throw new ClsTokenValidateException([
                'msg' => '[RK]Token validate failed.',
            ]);
        }
        if ($timeNow > $tokenInfo['refresh_expire']) {
            throw new ClsTokenExpiredException([
                'msg' => '[R]Token expired.',
            ]);
        }

        $req = request();
        if ($this->base_options['check_mode'] > 1) {
            $moreRes = self::checkMore(
                $this->base_options['check_mode'],
                [
                    'user_agent' => $req->header('user-agent'),
                    'ip'         => $req->ip(),
                ],
                [

                    'user_agent' => $tokenInfo['user_agent'],
                    'ip'         => $tokenInfo['ip'],
                ]
            );
            if (!$moreRes) {
                throw new ClsTokenValidateException([
                    'msg' => '[2/3R]Token validate failed.',
                ]);
            }
        }

        // 校验通过,可以更新了
        $returnData = [
            'token'          => Util::randomString('token_update' . $userIdentifier),
            'expire'         => $this->base_options['expire'],
            'refresh_token'  => $refreshToken,
            'refresh_expire' => $this->base_options['refresh_expire'],
        ];

        $setData = [
            'token'           => $returnData['token'],
            'expire'          => $timeNow + $returnData['expire'],
            'user_identifier' => (string)$userIdentifier,
            'refresh_token'   => $tokenInfo['refresh_token'],
            'refresh_expire'  => $tokenInfo['refresh_expire'],
            'ex_info'         => $tokenInfo['ex_info'],
            'user_agent'      => Util::hashUA($req->header('user-agent', '')),
            'ip'              => $req->ip(),
            'update_time'     => $timeNow,
        ];

        if (!empty($this->base_options['save_algo'])) {
            $key = empty($this->base_options['save_secret_key']) ?
                self::$default_secret_key
                : $this->base_options['save_secret_key'];

            $setData['token'] = hash_hmac(
                $this->base_options['save_algo'],
                $returnData['token'],
                $key
            );
        }

        $this->set($setData);

        return $returnData;

    }

    /**
     * 验证token有效性
     * 通过返回true,不通过抛出异常
     *
     * @param string     $token          待验证的令牌字符串
     * @param int|string $userIdentifier 用户唯一标识(ID/UUID)
     * @param string     $checkType      待校验令牌类型 "access"(默认) / "refresh"
     *
     * @return bool
     * @throws ClsTokenException
     * @throws ClsTokenEmptyException
     * @throws ClsTokenExpiredException
     * @throws ClsTokenValidateException
     */
    final public function check(
        string     $token,
        int|string $userIdentifier,
        string     $checkType = TokenConstant::CHECK_TYPE_ACCESS
    ): bool
    {
        if ($checkType === TokenConstant::CHECK_TYPE_ACCESS) {
            // 登录令牌
            $tokenField  = 'token';
            $expireField = 'expire';
        }
        elseif ($checkType === TokenConstant::CHECK_TYPE_REFRESH) {
            // 刷新令牌
            $tokenField  = 'refresh_token';
            $expireField = 'refresh_expire';
        }
        else {
            throw new ClsTokenException([
                'msg' => '[C]Token type invalid.',
            ]);
        }

        $timeNow = time();

        $tokenInfo = $this->getOriginData($userIdentifier);
        if (empty($tokenInfo[$tokenField])) {
            throw new ClsTokenEmptyException([
                'msg' => '[X]Token is empty.',
            ]);
        }
        if (strtolower($this->hashToken($token)) !== strtolower($tokenInfo[$tokenField])) {
            throw new ClsTokenValidateException([
                'msg' => '[V]Token validate failed.',
            ]);
        }
        if (strtolower($userIdentifier) !== strtolower($tokenInfo['user_identifier'])) {
            throw new ClsTokenValidateException([
                'msg' => '[K]Token validate failed.',
            ]);
        }
        if ($timeNow > $tokenInfo[$expireField]) {
            throw new ClsTokenExpiredException();
        }

        if ($this->base_options['check_mode'] > 1) {
            $req     = request();
            $moreRes = self::checkMore(
                $this->base_options['check_mode'],
                [
                    'user_agent' => $req->header('user-agent'),
                    'ip'         => $req->ip(),
                ],
                [

                    'user_agent' => $tokenInfo['user_agent'],
                    'ip'         => $tokenInfo['ip'],
                ]
            );
            if (!$moreRes) {
                throw new ClsTokenValidateException([
                    'msg' => '[2/3]Token validate failed.',
                ]);
            }
        }

        return true;
    }

    /**
     * 获取当前驱动的配置
     *      [ 'driver'=>驱动配置数组 , 'base'=>基础配置数组 ]
     *
     * @return array
     */
    public function getOptions()
    {
        return [
            'driver' => $this->options,
            'base'   => $this->base_options,
        ];
    }

    /**
     * 返回句柄对象，可执行其它高级方法
     *
     * @return object
     */
    public function handler()
    {
        return $this->handler;
    }

    /**
     * 存储令牌及相关信息
     *
     * @param array $tokenData 待存储的token数组
     *                         [
     *                         "user_identifier" => "用户标识id/uuid",
     *                         "token" => "",
     *                         "expire" => n1到期时间戳,
     *                         "refresh_token" => "",
     *                         "refresh_expire" => n2到期时间戳,
     *                         "ex_info" => [用户其他信息数组,注意有些存储驱动需要转成字串],
     *                         "user_agent" => "UA",
     *                         "ip" => "x.x.x.x",
     *                         "update_time" => 记录更新时间戳
     *                         ]
     */
    abstract public function set(array $tokenData);

    /**
     * 获取指定用户的token记录数据
     *      没找到返回空数组(注意有些存储驱动针对ex_info保存时处理为字串,取值时需要再转加数组)
     *
     * @param int|string $userIdentifier 用户标识(id/uuid)
     *
     * @return array
     */
    abstract public function get(int|string $userIdentifier);

    /**
     * 删除token记录
     *      返回false不一定表示记录还在,有可能根本就没相应记录
     *
     * @param int|string $userIdentifier token记录中保存的用户唯一标识(用户id/uuid)
     *
     * @return bool
     */
    abstract public function delete(int|string $userIdentifier);

    /**
     * 获取指定用户的token记录原始数据
     *
     * @param int|string $userIdentifier 用户标识(id/uuid)
     *
     * @return array
     */
    abstract protected function getOriginData(int|string $userIdentifier);

    public function __call($method, $args)
    {
        return call_user_func_array([$this->handler, $method], $args);
    }

    /**
     * 将token字符串进行hash编码
     *      按配置中的加密算法进行；如果加密算法设置为空，原样返回token
     *
     * @param string $token 待编码的token字符串
     *
     * @return string
     */
    protected function hashToken(string $token): string
    {
        if (empty($this->base_options['save_algo'])) {
            return $token;
        }

        $key = empty($this->base_options['save_secret_key']) ?
            self::$default_secret_key
            : $this->base_options['save_secret_key'];

        return hash_hmac(
            $this->base_options['save_algo'],
            $token,
            $key
        );
    }


    /**
     * @param int   $configMode 配置的校验类型  2/3
     * @param array $reqData    请求数据(request取到的 user-agent:下标"user_agent" 及 ip下标"ip")
     * @param array $tokenInfo  token记录数据("user_agent" "ip")
     *
     * @return bool
     */
    protected static function checkMore(
        int   $configMode,
        array $reqData,
        array $tokenInfo
    ): bool
    {
        if ($configMode < 2) {
            return true;
        }

        // 校验UA
        if (empty($tokenInfo['user_agent'])) {
            return false;
        }

        $ua = Util::hashUA($reqData['user_agent']);
        if ($ua !== $tokenInfo['user_agent']) {
            return false;
        }
        if ($configMode > 2) {
            // 还要校验IP
            if (
                empty($reqData['ip'])
                || empty($tokenInfo['ip'])
                || strtolower($reqData['ip']) !== strtolower($tokenInfo['ip'])
            ) {
                return false;
            }
        }
        return true;
    }

}